Skip to content

feat(ui): add action bar#1672

Open
MatteoGabriele wants to merge 15 commits intonpmx-dev:mainfrom
MatteoGabriele:feat/action-bar
Open

feat(ui): add action bar#1672
MatteoGabriele wants to merge 15 commits intonpmx-dev:mainfrom
MatteoGabriele:feat/action-bar

Conversation

@MatteoGabriele
Copy link
Contributor

@MatteoGabriele MatteoGabriele commented Feb 26, 2026

🔗 Linked issue

resolves #1509

🧭 Context

Added a multi-select feature to the search page that allows users to select multiple packages and perform bulk actions on them. Currently supports comparing selected packages, with the possibility of adding more actions in the future.

📚 Description

  • Selection UI: Added checkboxes to package cards that appear on hover. Selected cards are visually indicated with a border highlight and checkbox state.
  • Persistent counter: New "View selected (X)" button in the toolbar shows active selections and navigates to a dedicated view for managing them.
  • Floating action bar: When items are selected, a floating action bar appears at the bottom with the selection count, primary action (Compare), and clear button.
  • Selection state management: Uses composable to maintain selections across view changes (card/table/selections view), allowing users to continue browsing while keeping their selections.
  • Accessibility: Includes aria-live announcements for selection changes and keyboard shortcuts ("b" key) to focus the action bar.
Screen.Recording.2026-02-27.at.18.33.58.mov

@vercel
Copy link

vercel bot commented Feb 26, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 27, 2026 5:49pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 27, 2026 5:49pm
npmx-lunaria Ignored Ignored Feb 27, 2026 5:49pm

Request Review

@github-actions
Copy link

github-actions bot commented Feb 26, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
lunaria/files/en-GB.json Localization changed, will be marked as complete. 🔄️
lunaria/files/en-US.json Source changed, localizations will be marked as outdated.
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@codecov
Copy link

codecov bot commented Feb 27, 2026

Codecov Report

❌ Patch coverage is 63.49206% with 23 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/components/Package/ActionBar.vue 47.36% 6 Missing and 4 partials ⚠️
app/composables/usePackageSelection.ts 46.66% 7 Missing and 1 partial ⚠️
app/components/Package/ListToolbar.vue 50.00% 3 Missing and 1 partial ⚠️
app/components/Package/SelectionView.vue 80.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 27, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5e926ab and d04f0d2.

📒 Files selected for processing (1)
  • app/components/Package/ActionBar.vue
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/components/Package/ActionBar.vue

📝 Walkthrough

Walkthrough

This pull request adds package selection to the search results UI. A new composable usePackageSelection manages selection state and params. UI changes include a selected prop on BaseCard, new components ActionBar and SelectionView, selection checkboxes integrated into Card, TableRow and List, a toolbar toggle, and a dedicated selection view on the search page. Internationalisation keys and the ColumnId type are extended for a selection column. Accessibility tests for the action bar and selection view were added.

Suggested labels

front, a11y

Suggested reviewers

  • danielroe
  • knowler
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description clearly relates to the changeset, describing the multi-select feature, selection UI, persistent counter, floating action bar, state management, and accessibility features implemented throughout the modified files.
Linked Issues check ✅ Passed The pull request fulfils the core requirement from issue #1509 to allow users to select multiple packages from search results and compare them without visiting individual package pages.
Out of Scope Changes check ✅ Passed All changes are directly aligned with the linked issue objectives. New components, composables, translations, and UI updates all contribute to the multi-select and comparison workflow without introducing unrelated functionality.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (2)
app/composables/usePackageSelection.ts (1)

1-32: Clean composable implementation.

The use of useState ensures SSR-safe shared state across components. The immutable array operations (filter, spread) correctly trigger Vue's reactivity.

One consideration: there's no maximum selection limit enforced here. If the compare feature has a package limit (as suggested by compare.selector.packages_selected mentioning {count}/{max}), you may want to enforce it in togglePackageSelection to prevent users from selecting more packages than can be compared.

test/nuxt/a11y.spec.ts (1)

3323-3347: Cover the selected-state render path, not only the empty/default path.

Both new tests mount without arranging selection data, so they mostly validate non-interactive states. Please add at least one case with selected packages to audit the actual action controls and announcements.

Based on learnings: Applies to **/*.{test,spec}.{ts,tsx} : Write unit tests for core functionality using vitest.


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 365bd9f and 5e926ab.

📒 Files selected for processing (17)
  • app/components/BaseCard.vue
  • app/components/ColumnPicker.vue
  • app/components/Package/ActionBar.vue
  • app/components/Package/Card.vue
  • app/components/Package/List.vue
  • app/components/Package/ListToolbar.vue
  • app/components/Package/SelectionView.vue
  • app/components/Package/Table.vue
  • app/components/Package/TableRow.vue
  • app/composables/usePackageSelection.ts
  • app/pages/search.vue
  • i18n/locales/en.json
  • i18n/schema.json
  • lunaria/files/en-GB.json
  • lunaria/files/en-US.json
  • shared/types/preferences.ts
  • test/nuxt/a11y.spec.ts

Comment on lines +40 to +51
<button @click="clearSelectedPackages" class="flex items-center ms-2 hover:text-fg-muted">
<span class="i-lucide:x text-xs relative top-px" aria-label="Close action bar" />
</button>
</div>

<LinkBase
:to="{ name: 'compare', query: { packages: selectedPackagesParam } }"
variant="button-secondary"
classicon="i-lucide:git-compare"
>
Compare
</LinkBase>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The action-bar controls need accessible labelling and localisation cleanup.

Line 40-42 uses an icon-only close button without a button-level label, and Line 50 hardcodes visible text (Compare) instead of using i18n.

✅ Suggested patch
-          <button `@click`="clearSelectedPackages" class="flex items-center ms-2 hover:text-fg-muted">
-            <span class="i-lucide:x text-xs relative top-px" aria-label="Close action bar" />
+          <button
+            type="button"
+            :aria-label="$t('common.close')"
+            `@click`="clearSelectedPackages"
+            class="flex items-center ms-2 hover:text-fg-muted"
+          >
+            <span class="i-lucide:x text-xs relative top-px" aria-hidden="true" />
           </button>
@@
-          Compare
+          {{ $t('package.links.compare') }}
         </LinkBase>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button @click="clearSelectedPackages" class="flex items-center ms-2 hover:text-fg-muted">
<span class="i-lucide:x text-xs relative top-px" aria-label="Close action bar" />
</button>
</div>
<LinkBase
:to="{ name: 'compare', query: { packages: selectedPackagesParam } }"
variant="button-secondary"
classicon="i-lucide:git-compare"
>
Compare
</LinkBase>
<button
type="button"
:aria-label="$t('common.close')"
`@click`="clearSelectedPackages"
class="flex items-center ms-2 hover:text-fg-muted"
>
<span class="i-lucide:x text-xs relative top-px" aria-hidden="true" />
</button>
</div>
<LinkBase
:to="{ name: 'compare', query: { packages: selectedPackagesParam } }"
variant="button-secondary"
classicon="i-lucide:git-compare"
>
{{ $t('package.links.compare') }}
</LinkBase>

Comment on lines +70 to +72
<span class="sr-only">
{{ $t('package.card.select') }}
</span>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make checkbox accessible names package-specific.

Each checkbox currently announces the same label (“Select package”), which is ambiguous when navigating multiple results with assistive tech. Include the package name in the accessible label.

💡 Suggested tweak
-          <span class="sr-only">
-            {{ $t('package.card.select') }}
-          </span>
+          <span class="sr-only">
+            {{ `${$t('package.card.select')}: ${result.package.name}` }}
+          </span>

Comment on lines +229 to +231
<button @click="clearSelectedPackages" class="flex items-center ms-2">
<span class="i-lucide:x text-sm" aria-label="Close action bar" />
</button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clear-selection icon button is missing a reliable accessible name.

Line 229 renders an icon-only button, but the label is on the child <span> (Line 230), not on the <button> itself.

✅ Suggested patch
-          <button `@click`="clearSelectedPackages" class="flex items-center ms-2">
-            <span class="i-lucide:x text-sm" aria-label="Close action bar" />
+          <button
+            type="button"
+            :aria-label="t('common.close')"
+            `@click`="clearSelectedPackages"
+            class="flex items-center ms-2"
+          >
+            <span class="i-lucide:x text-sm" aria-hidden="true" />
           </button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button @click="clearSelectedPackages" class="flex items-center ms-2">
<span class="i-lucide:x text-sm" aria-label="Close action bar" />
</button>
<button
type="button"
:aria-label="t('common.close')"
`@click`="clearSelectedPackages"
class="flex items-center ms-2"
>
<span class="i-lucide:x text-sm" aria-hidden="true" />
</button>

Comment on lines +2 to +4
defineProps<{
viewMode?: ViewMode
}>()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Verify ViewMode import status in the target file and check for global declarations
rg -n "defineProps<\{|viewMode\?: ViewMode|import.*ViewMode|export.*ViewMode" app/components/Package/SelectionView.vue

# Check for global ViewMode declarations
rg -n "type ViewMode|interface ViewMode|declare.*ViewMode" --type ts --type d.ts

Repository: npmx-dev/npmx.dev

Length of output: 146


🏁 Script executed:

# Read the full SelectionView.vue file to see all imports
cat -n app/components/Package/SelectionView.vue

# Search for ViewMode type definition globally with proper patterns
rg "type ViewMode|interface ViewMode" --type ts

# Check if the suggested import path exists
fd "preferences" --type f

Repository: npmx-dev/npmx.dev

Length of output: 1753


🏁 Script executed:

# Search for TypeScript/Nuxt configuration to verify the `#shared` alias
fd "tsconfig\|nuxt\.config\|vite\.config" --type f

# Check for any global type declarations
rg "declare global|declare module|ambient" --type ts

# Search for any .d.ts files at root or shared directories
find . -name "*.d.ts" -type f | head -20

Repository: npmx-dev/npmx.dev

Length of output: 514


🏁 Script executed:

# Find and inspect TypeScript/Nuxt config files
cat -n tsconfig.json 2>/dev/null || echo "tsconfig.json not found"
cat -n nuxt.config.ts 2>/dev/null || echo "nuxt.config.ts not found"

# Also check for alias resolution in vite config or other config
find . -maxdepth 2 -name "nuxt.config.*" -o -name "tsconfig.json" | head -5

Repository: npmx-dev/npmx.dev

Length of output: 13729


🏁 Script executed:

# Check if Nuxt auto-generates `#shared` alias by looking for nuxt auto-import config
# or check if there's any auto-import configuration for types
rg "auto|#shared" nuxt.config.ts

# Verify if ViewMode is used elsewhere and how it's imported
rg "ViewMode" --type ts --type vue | head -30

Repository: npmx-dev/npmx.dev

Length of output: 145


🏁 Script executed:

# Check how ViewMode is imported in other files
rg "import.*ViewMode" --type ts

# Also check the app's tsconfig to see if it has the `#shared` alias
cat -n .nuxt/tsconfig.app.json 2>/dev/null | head -50

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Check all files in .nuxt directory that might have path aliases
find .nuxt -name "tsconfig*.json" -type f | head -10

# Check if any tsconfig actually has the `#shared` path
rg "#shared" --type ts

# Let's look for any import of ViewMode in the entire codebase
rg "ViewMode" | grep -i import

Repository: npmx-dev/npmx.dev

Length of output: 18137


ViewMode type is unresolved—add missing import.

The defineProps and template usage reference ViewMode without an import. The type is exported from #shared/types/preferences and must be imported locally in this file.

 <script setup lang="ts">
+import type { ViewMode } from '#shared/types/preferences'
+
 defineProps<{
   viewMode?: ViewMode
 }>()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
defineProps<{
viewMode?: ViewMode
}>()
<script setup lang="ts">
import type { ViewMode } from '#shared/types/preferences'
defineProps<{
viewMode?: ViewMode
}>()

Comment on lines +303 to +305
<th class="w-8">
<span class="sr-only">{{ getColumnLabel('selection') }}</span>
</th>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Selection header introduces a loading-state column mismatch.

After adding this <th>, skeleton rows still render fewer <td> cells, so the table structure is inconsistent while loading.

💡 Suggested patch
-          <th class="w-8">
+          <th scope="col" class="w-8">
             <span class="sr-only">{{ getColumnLabel('selection') }}</span>
           </th>
         <template v-if="isLoading && results.length === 0">
           <tr v-for="i in 5" :key="`skeleton-${i}`" class="border-b border-border">
@@
             <td v-if="isColumnVisible('keywords')" class="py-3 px-3">
               <div class="h-4 w-32 bg-bg-muted rounded animate-pulse ms-auto" />
             </td>
+            <td class="py-3 px-3 w-8">
+              <div class="h-4 w-4 bg-bg-muted rounded animate-pulse ms-auto" />
+            </td>
           </tr>
         </template>

Comment on lines +570 to +578
<button
v-if="isSelectioView"
type="button"
class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70 shrink-0"
@click="hideSelectionView"
>
<span class="i-lucide:arrow-left rtl-flip w-4 h-4" aria-hidden="true" />
<span class="hidden sm:inline">{{ $t('nav.back') }}</span>
</button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Back button needs an explicit accessible name, and should use the global focus-visible rule.

On small screens, Line 577 hides the visible text while Line 576 icon is aria-hidden, so the control may be unnamed. Also, Line 573 adds a per-element focus-visible utility on a <button>.

✅ Suggested patch
         <button
           v-if="isSelectioView"
           type="button"
-          class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70 shrink-0"
+          :aria-label="$t('nav.back')"
+          class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded shrink-0"
           `@click`="hideSelectionView"
         >

Based on learnings: In the npmx.dev project, focus-visible styling for button and select elements is implemented globally in app/assets/main.css; avoid per-element inline focus-visible utility classes on those elements.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
v-if="isSelectioView"
type="button"
class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70 shrink-0"
@click="hideSelectionView"
>
<span class="i-lucide:arrow-left rtl-flip w-4 h-4" aria-hidden="true" />
<span class="hidden sm:inline">{{ $t('nav.back') }}</span>
</button>
<button
v-if="isSelectioView"
type="button"
:aria-label="$t('nav.back')"
class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded shrink-0"
`@click`="hideSelectionView"
>
<span class="i-lucide:arrow-left rtl-flip w-4 h-4" aria-hidden="true" />
<span class="hidden sm:inline">{{ $t('nav.back') }}</span>
</button>

| 'maintenanceScore'
| 'combinedScore'
| 'security'
| 'selection'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if selection column is used in column visibility checks anywhere
rg -n "isColumnVisible.*selection" --type vue
rg -n "selection.*visible" --type ts

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

#!/bin/bash
# Let's look at the actual file structure
find . -name "preferences.ts" -type f | head -5

Repository: npmx-dev/npmx.dev

Length of output: 90


🏁 Script executed:

#!/bin/bash
# Look at the DEFAULT_COLUMNS structure and surrounding context
rg -B 5 -A 50 "DEFAULT_COLUMNS" --type ts | head -100

Repository: npmx-dev/npmx.dev

Length of output: 5575


🏁 Script executed:

#!/bin/bash
# Check ColumnPicker.vue for columnLabels usage with 'selection'
fd "ColumnPicker.vue" --type f -x cat -n {} | head -150

Repository: npmx-dev/npmx.dev

Length of output: 5508


🏁 Script executed:

#!/bin/bash
# Check TableRow.vue for selection column rendering
fd "TableRow.vue" --type f -x cat -n {} | head -200

Repository: npmx-dev/npmx.dev

Length of output: 8063


🏁 Script executed:

#!/bin/bash
# Search for selection checkbox rendering logic
rg -n "selection\|checkbox\|togglePackageSelection" --type ts --type tsx -B 2 -A 2 | head -80

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

#!/bin/bash
# Look for the full TableRow.vue template to see if selection is rendered separately
fd "TableRow.vue" --type f -x cat -n {} | tail -100

Repository: npmx-dev/npmx.dev

Length of output: 3876


🏁 Script executed:

#!/bin/bash
# Check the ColumnId type definition to see what it includes
rg -B 5 -A 15 "type ColumnId" shared/types/preferences.ts

Repository: npmx-dev/npmx.dev

Length of output: 431


Remove unnecessary selection label from ColumnPicker.vue.

The selection column is added to the ColumnId type correctly. However, the selection entry in columnLabels (ColumnPicker.vue, line 57) is unused. Since selection is not included in DEFAULT_COLUMNS, it will never appear in the toggleable columns list. The selection checkbox is rendered as a separate table cell in TableRow.vue (lines 204–219) and is always visible, not controlled through the column visibility system. Remove the selection entry from columnLabels to avoid dead code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ux Related to wider UX decisions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Select packages to compare from the search results view

2 participants